package cn.imatu.framework.sign.core;

import cn.hutool.core.util.ClassUtil;
import cn.hutool.core.util.RandomUtil;
import cn.imatu.framework.exception.BaseException;
import cn.imatu.framework.tool.core.StringUtils;
import cn.imatu.framework.tool.core.exception.Assert;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import com.alibaba.fastjson2.JSONWriter;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.time.StopWatch;

import java.math.BigDecimal;
import java.time.Duration;
import java.time.Instant;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;

/**
 * 签名工具
 *
 * 一般情况, 生成签名方, 除了密钥key需要根据签名算法决定是否排除外, 待签名的数据不需要进行排除任何字段,
 * 只有在校验签名方, 可以数据是来源于前端, 接口中的签名数据一部分是来自上一个接口的签名结果, 另一部分是用户手动输入或者
 * 其他自定义字段, 这时候这些字段是需要排除的, 否则校验会失败
 *
 * 为了避免生成签名方和校验签名方数据偶尔, 建议校验签名方在springboot中, 使用JSONObject类型接收参数
 *
 * @author shenguangyang
 */
@Slf4j
public class SignUtils {
    /**
     * 签名有效时间
     */
    public static Integer SIGN_TIMEOUT_SECONDS = 60 * 5;
    /**
     * 检验签名
     */
    public static void checkSign(Object data, String secretKey, String... excludeKeys) {
        checkSign(SIGN_TIMEOUT_SECONDS, data, true, secretKey, excludeKeys);
    }

    /**
     * 检验签名
     */
    public static void checkSign(Integer timeoutSeconds, Object data, String secretKey, String... excludeKeys) {
        checkSign(timeoutSeconds, data, true, secretKey, excludeKeys);
    }

    /**
     * 检验签名
     * @param isCheckTimeOut 是否检查超时时间
     * @param secretKey 密钥key, 如果采用非对称方式生成签名, 那么该值必须是公钥
     * @param excludeKeys 排除的签名key, 支持多级对象, 字段之间用 . 分割, 比如 user.password
     * @param timeoutSeconds 签名超时时间
     */
    public static void checkSign(Integer timeoutSeconds, Object data, boolean isCheckTimeOut, String secretKey, String... excludeKeys) {
        try {
            if (Objects.isNull(data)) {
                return;
            }
            Set<String> excludeKeyList = excludeKeys == null ? Collections.emptySet() : Arrays.stream(excludeKeys).collect(Collectors.toSet());

            Object dataJsonObject = JSON.toJSON(data);

            if (!(dataJsonObject instanceof JSONObject)) {
                log.error("签名数据只能是对象结构, 不能是集合、基本类型等结构");
                throw new SignException("无效的签名数据");
            }
            JSONObject jsonObject = (JSONObject) dataJsonObject;
            Assert.notEmpty(secretKey, "签名密钥不能为空");
            SignPayload.check(jsonObject);

            // 验证url是否超时
            if (isCheckTimeOut) {
                checkUrlTimeOut(timeoutSeconds, jsonObject);
            }

            // 校验签名
            doCheckSign(jsonObject, secretKey, excludeKeyList);

        } catch (Exception e) {
            if (e instanceof BaseException) {
                throw (BaseException) e;
            }
            log.error("signPayload: {}", JSON.toJSONString(data));
            throw new SignException("签名校验失败");
        }
    }

    /**
     * 完成生成签名 <br/>
     * @param data 数据, 如果继承 {@link SignPayload} 则会自动把签名信息填充到 {@link SignPayload} 中
     * @param secretId 密钥id
     * @param secretKey 密钥key, 如果采用非对称方式生成签名, 那么该值必须是私钥
     */
    @SuppressWarnings("unchecked")
    public static JSONObject genSign(SignType signType, String secretId, String secretKey, Object data) throws Exception {
        if (data == null) {
            return null;
        }

        StopWatch watch = StopWatch.createStarted();
        String ts = String.valueOf(System.currentTimeMillis());
        // 随机数，生成随机数主要保证签名的多变性
        String nonce = RandomUtil.randomString(32);

        boolean isHashAlgo = signType.isHashAlgo();
        // 如果不是hash算法, 则需要排除密钥key
        Set<String> excludeKeys = new HashSet<>();
        if (!isHashAlgo) {
            excludeKeys.add(SignPayload.SECRET_KEY);
        }

        // 设置签名信息
        SignPayload signPayload = null;
        JSONObject jsonData = (JSONObject) JSON.toJSON(data);
        if (data instanceof SignPayload) {
            signPayload = (SignPayload) data;
            signPayload.setSignType(signType.getType());
            signPayload.setNonce(nonce);
            signPayload.setTimeStamp(ts);
            signPayload.setSecretId(secretId);
        }

        jsonData.put(SignPayload.SIGN_TYPE, signType.getType());
        jsonData.put(SignPayload.NONCE, nonce);
        jsonData.put(SignPayload.TIME_STAMP, ts);
        jsonData.put(SignPayload.SECRET_ID, secretId);

        // 生成签名
        String signData = sortParams(jsonData , excludeKeys, secretId, secretKey);
        String sign = HashAlgoProcess.getAlgoProcess(isHashAlgo ? signType : SignType.SHA256).process(signData);
        if (signType == SignType.RSA2) {
            sign = RsaUtils.encrypt(sign, secretKey);
        }

        // 设置签名信息
        if (signPayload != null) {
            signPayload.setSign(sign);
        } else if (data instanceof Map<?, ?>) {
            Map<String, Object> value = (Map<String, Object>) data;
            value.put(SignPayload.SIGN_TYPE, signType.getType());
            value.put(SignPayload.NONCE, nonce);
            value.put(SignPayload.TIME_STAMP, ts);
            value.put(SignPayload.SECRET_ID, secretId);
            value.put(SignPayload.SIGN, sign);
        }
        jsonData.put(SignPayload.SIGN, sign);

        log.debug("genSign ===> time: {} ms, signData: {}", watch.getTime(TimeUnit.MILLISECONDS), signData);
        watch.stop();
        return jsonData;
    }

    private static void checkUrlTimeOut(Integer timeoutSeconds, JSONObject data) {
        String startTimeStamp = data.getString(SignPayload.TIME_STAMP);

        long timeOut = 1000L * timeoutSeconds;
        Instant startTime = Instant.ofEpochMilli(Long.parseLong(startTimeStamp));

        if (Duration.between(startTime, Instant.now()).abs().toMillis() >= timeOut) {
            log.warn("请求从客户端到服务端所用的时间超出 {} ms", timeOut);
            throw new SignException("签名已过期");
        }
    }

    /**
     *
     * @param secretKey 密钥key, 如果采用非对称方式生成签名, 那么该值必须是公钥
     */
    private static void doCheckSign(JSONObject dataMap, String secretKey, Set<String> excludeKeys) throws Exception {
        String sign = dataMap.getString(SignPayload.SIGN);
        if (StringUtils.isEmpty(sign)) {
            throw new SignException("签名校验失败-签名为空");
        }
        StopWatch watch = StopWatch.createStarted();

        SignType signType = SignType.ofByType(dataMap.getString(SignPayload.SIGN_TYPE));
        boolean isHashAlgo = signType.isHashAlgo();
        excludeKeys.add(SignPayload.SIGN);

        // 如果不是hash算法, 则需要排除密钥key
        if (!isHashAlgo) {
            excludeKeys.add(SignPayload.SECRET_KEY);
        }
        if (signType == SignType.RSA2) {
            sign = RsaUtils.decrypt(sign, secretKey);
        }

        String secretId = dataMap.getString(SignPayload.SECRET_ID);
        String signData = sortParams(dataMap, excludeKeys, secretId, secretKey);
        String genSign = HashAlgoProcess.getAlgoProcess(isHashAlgo ? signType : SignType.SHA256).process(signData);
        if (!sign.equals(genSign)) {
            watch.stop();
            log.error("checkSignFail ==> signData: {}", signData);
            throw new SignException("签名校验失败");
        }
        log.debug("checkSign ==> time: {} ms, signData: {}", watch.getTime(TimeUnit.MILLISECONDS), signData);
        watch.stop();
    }

    /**
     * 排序规则如下
     *
     * 对参数进行递归排序并进行签名
     */
    private static String sortParams(JSONObject params, Set<String> excludeKeys, String secretId, String secretKey) {
        try {
            JSONObject jsonObject = new JSONObject();
            excludeKeys.forEach(params::remove);
            params.put(SignPayload.SECRET_ID, secretId);
            params.put(SignPayload.SECRET_KEY, secretKey);

            // 过滤掉数据值为空的键值对
            for (String key : params.keySet()) {
                Object value = params.get(key);
                if (value != null) {
                    jsonObject.put(key, params.get(key));
                }
            }

            return JSONObject.toJSONString(transToLowerObject(jsonObject, excludeKeys, ""), JSONWriter.Feature.MapSortField);
        } finally {
            params.remove(SignPayload.SECRET_KEY);
        }
    }

    private static JSONObject transToLowerObject(JSONObject req, Set<String> excludeKeys, String objKeys) {
        JSONObject dto = new JSONObject();
        for (String key : req.keySet()) {
            Object value = req.get(key);
            if (value == null) {
                continue;
            }
            // 支持排除字段
            String newObjKeys = (StringUtils.isEmpty(objKeys) ? "" : objKeys + ".") + key;
            if (excludeKeys.contains(newObjKeys)) {
                continue;
            }
            if (value instanceof JSONObject) {
                dto.put(key.toLowerCase(), transToLowerObject((JSONObject)value, excludeKeys, newObjKeys));
            } else if (value instanceof JSONArray) {
                dto.put(key.toLowerCase(), transToArray(req.getJSONArray(key), excludeKeys, newObjKeys));
            } else if (ClassUtil.isBasicType(value.getClass())) {
                dto.put(key.toLowerCase(), value.toString());
            } else if (value instanceof BigDecimal) {
                // 统一 BigDecimal 值, 避免签名失败
                String plainString = ((BigDecimal) value).toPlainString();
                dto.put(key.toLowerCase(), plainString);
            } else {
                value = JSON.toJSON(value);
                if (value instanceof JSONObject) {
                    dto.put(key.toLowerCase(), transToLowerObject((JSONObject)value, excludeKeys, newObjKeys));
                } else if (value instanceof JSONArray) {
                    dto.put(key.toLowerCase(), transToArray((JSONArray) value, excludeKeys, newObjKeys));
                } else if (ClassUtil.isJdkClass(value.getClass())){
                    dto.put(key.toLowerCase(), value.toString());
                }
            }
        }
        return dto;
    }

    public static void main(String[] args) {
        BigDecimal bigDecimal = new BigDecimal("1.21");
        JSONObject data = new JSONObject();
        data.put("price", bigDecimal);
        System.out.println(JSON.parseObject(data.toJSONString()));
    }

    private static JSONArray transToArray(JSONArray jsonArray, Set<String> excludeKeys, String objKeys) {
        JSONArray resp = new JSONArray();
        for (Object obj : jsonArray) {
            if (obj instanceof JSONObject) {
                resp.add(transToLowerObject((JSONObject) obj, excludeKeys, objKeys));
            } else if (obj instanceof JSONArray) {
                resp.add(transToArray((JSONArray) obj, excludeKeys, objKeys));
            } else {
                obj = JSON.toJSON(obj);
                if (obj instanceof JSONObject) {
                    resp.add(transToLowerObject((JSONObject) obj, excludeKeys, objKeys));
                } else if (obj instanceof JSONArray) {
                    resp.add(transToArray((JSONArray) obj, excludeKeys, objKeys));
                } else if (isJavaClass(obj.getClass())){
                    resp.add(String.valueOf(obj));
                }
            }
        }
        return resp;
    }

    private static boolean isJavaClass(Class<?> clz) {
        return clz.getClassLoader() == null;
    }
}
